WatchView.tsx 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. 'use client';
  2. import { useState, useEffect } from 'react';
  3. import { ChannelDetail, ChannelStatusUpdate } from '@/types/channel';
  4. import { useSignalRContext } from '@/contexts/signalrProvider';
  5. import { buildChannelUrl, formatHandle } from '@/lib/utils/channel';
  6. import YouTubeChatIframe from './YouTubeChatIframe';
  7. import ShareMenu from './ShareMenu';
  8. import FollowButton from '@/app/component/FollowButton';
  9. import Link from 'next/link';
  10. import './style.scss';
  11. // [DEPRECATED] antooza SignalR 채팅 — YouTube Live Chat iframe 으로 대체.
  12. // quota 승인 후 재활성화 예정. 자세한 작업 목록: memory/plan_dpot_chat_reactivate_after_quota.md
  13. // import ChatSidebar from './ChatSidebar';
  14. type Props = {
  15. channel: ChannelDetail;
  16. };
  17. function formatCount(n: number): string {
  18. if (n >= 10000) {
  19. const v = (n / 10000).toFixed(n >= 100000 ? 0 : 1);
  20. return `${v.replace(/\.0$/, '')}만명`;
  21. }
  22. return `${n.toLocaleString()}명`;
  23. }
  24. export default function WatchView({ channel }: Props)
  25. {
  26. const [descOpen, setDescOpen] = useState(false);
  27. const [isLive, setIsLive] = useState(channel.isLive);
  28. const [videoId, setVideoId] = useState<string|null>(channel.videoId);
  29. const [viewerCount, setViewerCount] = useState(channel.viewerCount);
  30. const [origin, setOrigin] = useState<string|null>(null);
  31. // ChannelStatusBroadcaster 는 AppHub 의 channel:{sid} 그룹으로 송출하므로 appConnection 사용 필수.
  32. // (chatConnection 으로 받으면 그룹 키 미스매치 + ChatHub.JoinChannel 의 입장 메시지 도배 부작용)
  33. const { appConnection } = useSignalRContext();
  34. // SSR 시점엔 window 가 없으므로 마운트 후 origin 해석
  35. useEffect(() => {
  36. if (typeof window !== 'undefined') {
  37. setOrigin(window.location.origin);
  38. }
  39. }, []);
  40. // SignalR 실시간 채널 상태 업데이트 (AppHub.ReceiveChannelStatus — 라이브 시작/종료, 시청자 수)
  41. useEffect(() => {
  42. if (!appConnection) {
  43. return;
  44. }
  45. const handler = (status: ChannelStatusUpdate) => {
  46. if (status.channelSID !== channel.channelSID) {
  47. return;
  48. }
  49. setIsLive(status.isLive);
  50. setVideoId(status.videoId);
  51. setViewerCount(status.viewerCount);
  52. };
  53. appConnection.on('ReceiveChannelStatus', handler);
  54. return () => {
  55. appConnection.off('ReceiveChannelStatus', handler);
  56. };
  57. }, [appConnection, channel.channelSID]);
  58. // ChannelStatusBroadcaster 가 AppHub 의 Clients.Group("channel:{sid}") 으로 송출 → AppHub.JoinChannel 로 가입.
  59. // AppHub.JoinChannel 은 순수 그룹 가입만 수행 (사이드이펙트 없음).
  60. // 재연결 시에도 자동 재가입.
  61. useEffect(() => {
  62. if (!appConnection) {
  63. return;
  64. }
  65. const sid = channel.channelSID;
  66. const join = async () => {
  67. if (appConnection.state !== 'Connected') {
  68. return;
  69. }
  70. try {
  71. await appConnection.invoke('JoinChannel', sid);
  72. } catch (err) {
  73. console.warn('[WatchView] JoinChannel 실패:', sid, err);
  74. }
  75. };
  76. join();
  77. appConnection.onreconnected(join);
  78. return () => {
  79. if (appConnection.state !== 'Connected') {
  80. return;
  81. }
  82. appConnection.invoke('LeaveChannel', sid).catch(() => {});
  83. };
  84. }, [appConnection, channel.channelSID]);
  85. // YouTube embed URL.
  86. // - youtube-nocookie.com: 임베드 제한이 youtube.com 대비 관대 (privacy-enhanced mode)
  87. // - origin / widget_referrer: 호스트 명시 → "다른 웹사이트에서 재생 차단" 케이스 일부 회피
  88. // - enablejsapi=1: origin 인식에 필요
  89. // - playsinline=1: iOS Safari 인라인 재생
  90. // 채널 소유자가 YouTube Studio에서 "임베드 허용"을 끈 경우는 클라이언트로 해결 불가.
  91. const embedUrl = videoId
  92. ? `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&playsinline=1&enablejsapi=1${origin ? `&origin=${encodeURIComponent(origin)}&widget_referrer=${encodeURIComponent(origin)}` : ''}`
  93. : null;
  94. const shareTitle = channel.liveTitle || channel.name;
  95. const showViewers = isLive && viewerCount > 0;
  96. return (
  97. <div className="watch-page">
  98. <div className="watch-page__content">
  99. {/* 플레이어 */}
  100. <div className="watch-page__player">
  101. {embedUrl ? (
  102. <iframe
  103. src={embedUrl}
  104. allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
  105. allowFullScreen
  106. title={channel.liveTitle || channel.name}
  107. />
  108. ) : (
  109. <div className="watch-page__offline">
  110. <p>현재 방송 중이 아닙니다</p>
  111. <Link href={buildChannelUrl(channel)}>채널 페이지로 이동</Link>
  112. </div>
  113. )}
  114. </div>
  115. {/* 방송 정보 */}
  116. <div className="watch-page__info">
  117. <h1 className="watch-page__title">{channel.liveTitle || channel.name}</h1>
  118. <div className="watch-page__meta">
  119. <Link href={buildChannelUrl(channel)} className="watch-page__channel">
  120. {channel.thumbnailUrl ? (
  121. <img src={channel.thumbnailUrl} alt="" className="watch-page__avatar" />
  122. ) : (
  123. <span className="watch-page__avatar watch-page__avatar--default" aria-hidden="true">
  124. {channel.name.charAt(0)}
  125. </span>
  126. )}
  127. <span className="watch-page__channel-body">
  128. <span className="watch-page__channel-name">
  129. {channel.name}
  130. {channel.isVerified && (
  131. <span className="watch-page__verified" title="인증됨" aria-label="인증됨">✓</span>
  132. )}
  133. </span>
  134. <span className="watch-page__channel-sub">
  135. 구독자 {formatCount(channel.subscriberCount)}
  136. {channel.handle && <> · {formatHandle(channel.handle)}</>}
  137. {showViewers && (
  138. <>
  139. {' · '}
  140. <span className="watch-page__viewers" aria-label={`실시간 시청자 ${formatCount(viewerCount)}`}>
  141. <span className="watch-page__viewers-dot" aria-hidden="true" />
  142. 시청자 {formatCount(viewerCount)}
  143. </span>
  144. </>
  145. )}
  146. </span>
  147. </span>
  148. </Link>
  149. <div className="watch-page__actions">
  150. <FollowButton memberSID={channel.memberSID} className="watch-page__follow" />
  151. <ShareMenu title={shareTitle} />
  152. </div>
  153. </div>
  154. {channel.description && (
  155. <div className={`watch-page__desc ${descOpen ? 'watch-page__desc--open' : ''}`}>
  156. <p className="watch-page__desc-body">{channel.description}</p>
  157. <button
  158. type="button"
  159. className="watch-page__desc-toggle"
  160. onClick={() => setDescOpen((prev) => !prev)}
  161. aria-expanded={descOpen}
  162. >
  163. {descOpen ? '간략히' : '...더보기'}
  164. </button>
  165. </div>
  166. )}
  167. </div>
  168. </div>
  169. {/* 우측 채팅 — YouTube Live Chat iframe (라이브 시) */}
  170. <div className="watch-page__chat">
  171. <YouTubeChatIframe
  172. videoId={isLive ? videoId : null}
  173. />
  174. {/*
  175. * [DEPRECATED] antooza SignalR 채팅 — YouTube iframe 으로 대체.
  176. * quota 승인 후 재활성화 예정.
  177. * <ChatSidebar channelSID={channel.channelSID} />
  178. */}
  179. </div>
  180. </div>
  181. );
  182. }